Skip to content

Swift 6: complete concurrency checking #825

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 27 commits into
base: develop
Choose a base branch
from
Open

Swift 6: complete concurrency checking #825

wants to merge 27 commits into from

Conversation

laevandus
Copy link
Contributor

@laevandus laevandus commented May 7, 2025

🔗 Issue Links

Resolves: IOS-736

🎯 Goal

  • Set Swift version to 6 and implement complete concurrency checking

📝 Summary

  • Set Swift version to 6 in StreamChatSwiftUI and demo app (includes SWIFT_STRICT_CONCURRENCY = complete)
  • Add Sendable conformance to many types
  • Many protocols and types related to UI get @preconcurrency @MainActor requirement (e.g. view models)
  • Use StreamConcurrency.onMain for jumping on the main actor from controller delegates and completion handlers
  • Upgrade Nuke to version 12.8 (otherwise we can't compile with complete concurrency checking)
    • LazyImage has breaking changes in Nuke (more testing for image scaling and gifs is needed)
  • Patch SwiftyGif for supporting complete concurrency checking (not beautiful)

🛠 Implementation

🎨 Showcase

🧪 Manual Testing Notes

Full manual regression testing round is needed.

☑️ Contributor Checklist

  • I have signed the Stream CLA (required)
  • This change should be manually QAed
  • Changelog is updated with client-facing changes
  • Changelog is updated with new localization keys
  • New code is covered by unit tests
  • Documentation has been updated in the docs-content repo

@laevandus laevandus requested a review from a team as a code owner May 7, 2025 12:08
@laevandus laevandus marked this pull request as draft May 7, 2025 12:08
Copy link

github-actions bot commented May 7, 2025

1 Warning
⚠️ Big PR
1 Message
📖 There seems to be app changes but CHANGELOG wasn't modified.
Please include an entry if the PR includes user-facing changes.
You can find it at CHANGELOG.md.

Generated by 🚫 Danger

Comment on lines -36 to -40
replaceDeclaration ' Image?' ' NukeImage?' $f
replaceDeclaration ' Image(' ' NukeImage(' $f
replaceDeclaration 'struct Image:' 'struct NukeImage:' $f
replaceDeclaration 'extension Image {' 'extension NukeImage {' $f
replaceDeclaration 'Content == Image' 'Content == NukeImage' $f
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nuke was upgraded to 12.8 (supports Swift 6), some of the types are not there anymore

Comment on lines -32 to +33
static var `default`: Appearance = .init()
nonisolated(unsafe) static var `default`: Appearance = .init()
Copy link
Contributor Author

@laevandus laevandus May 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Appearance is a class in SwiftUI and its properties are accessible through InjectedValues.
@injected makes it impossible top make the Appearance itself @MainActor because @Injected(\.images) private var images can't ensure main actor isolation when a type/view X is created.
The other option would be to use a lock within Appearance to make sure everything is concurrency safe, but that feels like too much because the type itself is called from main anyway (if not mistakenly calling from background threads).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also fine to keep it like this, it anyway contains only UI stuff - shouldn't be called from a bg thread

@@ -66,7 +66,8 @@ jobs:
build-xcode15:
name: Build SDKs (Xcode 15)
runs-on: macos-15
if: ${{ github.event.inputs.record_snapshots != 'true' }}
#if: ${{ github.event.inputs.record_snapshots != 'true' }}
if: false
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Xcode 15 builds are going away, disabling it for now and a cleanup happens separately

Comment on lines +24 to 27
nonisolated(unsafe)
public static var localizationProvider: @Sendable(_ key: String, _ table: String) -> String = { key, table in
Bundle.streamChatUI.localizedString(forKey: key, value: nil, table: table)
}
Copy link
Contributor Author

@laevandus laevandus May 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternative could be making it @MainActor instead of opting out with nonisolated. But then all out generated L10n structs would need to be MainActor as well which will cause some troubles, since main actor isolation is not available everywhere where L10n is used. Probably fine to keep it unsafe for now.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is fine

static let queryIdentifiers = ChannelListQueryIdentifier.allCases.sorted(using: KeyPathComparator(\.title))
static var queryIdentifiers: [ChannelListQueryIdentifier] {
ChannelListQueryIdentifier.allCases.sorted(by: { $0.title < $1.title })
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was giving Sendable related error which did not feel right. Just switched to another sorting method.

@@ -42,7 +42,7 @@ class LoginViewModel: ObservableObject {
return
}

DispatchQueue.main.async { [weak self] in
Task { @MainActor [weak self] in
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DispatchQueue gives Sendable error for self

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, we should use Task going forward

@@ -6,7 +6,7 @@ import StreamChat
import SwiftUI

/// View model for the `AddUsersView`.
class AddUsersViewModel: ObservableObject {
@MainActor class AddUsersViewModel: ObservableObject {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since view model factory is MainActor and view models are used from view, then it made more than sense to make all the view models main actor.

guard let self = self else { return }
self.users = self.searchController.userArray
self.loadingNextUsers = false
MainActor.ensureIsolated { [weak self] in
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Controllers do not ensure on the compilation level which actor is used. We can use any queue for controller completion handlers although the default is main. Therefore, we need to make sure that main is used. Applies to all the completion handlers and controller delegates.

@@ -172,7 +172,7 @@ public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDe
loadAdditionalUsers()
}

public func leaveConversationTapped(completion: @escaping () -> Void) {
public func leaveConversationTapped(completion: @escaping @MainActor() -> Void) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For avoiding actor isolation in the view. Better force main on the view model level.

@@ -152,15 +152,16 @@ public struct MentionsCommandHandler: CommandHandler {

private func searchAllUsers(for typingMention: String) -> Future<SuggestionInfo, Error> {
Future { promise in
nonisolated(unsafe) let unsafePromise = promise
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Promise is not sendable even when making SuggestionInfo sendable, added a unsafe check here for bypassing it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should probably refactor this to use async

Comment on lines +20 to +31
private var images: Images { InjectedValues[\.images] }
private var utils: Utils { InjectedValues[\.utils] }

/// Context provided utils.
private lazy var imageProcessor = utils.imageProcessor
private lazy var imageMerger = utils.imageMerger
private var imageProcessor: ImageProcessor { utils.imageProcessor }
private var imageMerger: ImageMerging { utils.imageMerger }

/// Placeholder images.
private lazy var placeholder1 = images.userAvatarPlaceholder1
private lazy var placeholder2 = images.userAvatarPlaceholder2
private lazy var placeholder3 = images.userAvatarPlaceholder3
private lazy var placeholder4 = images.userAvatarPlaceholder4
private var placeholder1: UIImage { images.userAvatarPlaceholder1 }
private var placeholder2: UIImage { images.userAvatarPlaceholder2 }
private var placeholder3: UIImage { images.userAvatarPlaceholder3 }
private var placeholder4: UIImage { images.userAvatarPlaceholder4 }
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Making everything getter here avoids making lazy var properties concurrency safe. Note that these are indeed called from different threads.

@@ -36,7 +36,7 @@ open class ChannelHeaderLoader: ObservableObject {
private var loadedImages = [ChannelId: UIImage]()
private let didLoadImage = PassthroughSubject<ChannelId, Never>()

public init() {
nonisolated public init() {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Otherwise Utils can't init it in-place and adding any MainActor isolation to Utils is not feasible.

@@ -127,9 +127,9 @@ struct TabBarAccessor: UIViewControllerRepresentable {
}

var isIphone: Bool {
UIDevice.current.userInterfaceIdiom == .phone
UITraitCollection.current.userInterfaceIdiom == .phone
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UIDevice is main actor whereas UITraitCollection is not

/// Sweeps are performed in a background and can be performed in parallel
/// with reading.
var sweepInterval: TimeInterval = 30
/// The time interval between cache sweeps. The default value is 1 hour.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Everything under StreamNuke does not need reviewing since it was Nuke's version upgrade without any manual changes.

@@ -14,7 +14,7 @@ public struct StreamChatError: Error {
public let description: String?

/// The additional information dictionary.
public let additionalInfo: [String: Any]?
public nonisolated(unsafe) let additionalInfo: [String: Any]?
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It has Any, which can't be Sendable (note that Error is Sendable). Skipping the sendable check here (breaking change to change Any here)

Comment on lines 33 to 34
onDismiss: @escaping @MainActor() -> Void,
onError: @escaping @MainActor(Error) -> Void
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will give a build warning for SDK users who override it in their view factory. Needs to be highlighted in the CHANGELOG

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ouch, that's tricky, can we avoid it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll take it as a TODO to find a way to avoid it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went for reverting these changes since these will cause breaking changes for customers. Required some nonisolated(unsafe) handling.

Comment on lines +60 to 61
@preconcurrency @MainActor
public lazy var audioSessionFeedbackGenerator: AudioSessionFeedbackGenerator = StreamAudioSessionFeedbackGenerator()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apple's feedback generator's init is MainActor, therefore it is really difficult to not have our wrapper nonisolated.

@@ -22,7 +22,15 @@ public extension ChatClient {
config: config,
workerBuilders: [],
environment: .init(
apiClientBuilder: APIClient_Spy.init,
apiClientBuilder: {
APIClient_Spy(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sendable warning if not written out with all the arguments

@@ -21,7 +21,7 @@ open class StreamChatTestCase: XCTestCase {

public var streamChat: StreamChat?

override open func setUp() {
@MainActor override open func setUp() {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Many tests want to change main actor isolated types, therefore it is easier to require main actor in a single place

@@ -371,7 +371,7 @@ class MessageListPage {

enum ComposerMentions {
static var cells: XCUIElementQuery {
app.scrollViews["CommandsContainerView"].otherElements.matching(NSPredicate(format: "identifier LIKE 'MessageAvatarView'"))
app.scrollViews["CommandsContainerView"].images.matching(NSPredicate(format: "identifier LIKE 'MessageAvatarView'"))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nuke's LazyImage changes causes it

@laevandus laevandus added 🤞 Ready for QA ⏫ Dependencies Update Pull requests that update a dependency file 🪧 Demo App An Issue or PR related to the Demo App ✅ Feature An issue or PR related to a feature labels May 9, 2025
Copy link
Contributor

@martinmitrevski martinmitrevski left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Few things we should discuss:

  • the usage of assumeIsolated
  • we still use preconcurrency in some VMs
  • some delegate methods are nonisolated

@@ -214,7 +214,7 @@ struct BlurredBackground: View {
}

struct HeightPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat? = nil
static let defaultValue: CGFloat? = nil
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't this causing issues somewhere?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PreferenceKey's protocol requires the default value to be get, therefore this is fine to be defined with let over mutable var (which causes concurrency errors).
Docs

}
}
}

// MARK: - ChatUserSearchControllerDelegate

func controller(
nonisolated func controller(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we avoid this being nonisolated in the LLC?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nonisolated is required because I made the view model @MainActor which I feel like makes sense to have. Controllers have a design where I can set any queue to be the one used for delegate callbacks. In 99% of cases it is the default main queue (highly unlikely that many set their own queues). But because we support any queue for delegate callbacks, we can't make the delegate protocol @MainActor. We can't use @preconcurrency for the delegate conformance because then we would get runtime crash if anyone change the the default queue in the controller to be a background queue.
Ideally we would use our async-await state layer instead of delegates in the future, but that is out of scope.

Summary is that nonisolated can be avoided if view models are not @MainActor (I currenty changed them to be). That said, in the UIKit implementation view controllers are implementing delegates, so nonisolated is required there because UIViewController is @MainActor (similar situation). Similar issue with completion handlers where the calling thread can be any thread.

@@ -42,7 +42,7 @@ class LoginViewModel: ObservableObject {
return
}

DispatchQueue.main.async { [weak self] in
Task { @MainActor [weak self] in
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, we should use Task going forward

if granted {
DispatchQueue.main.async {
Task { @MainActor in
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good to test this part, I remember having crashes on video in the summer (probably it was Xcode bug, but still)

Comment on lines +24 to 27
nonisolated(unsafe)
public static var localizationProvider: @Sendable(_ key: String, _ table: String) -> String = { key, table in
Bundle.streamChatUI.localizedString(forKey: key, value: nil, table: table)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is fine

}
if newMembers.count > self.participants.count {
self.participants = newMembers
MainActor.ensureIsolated { [weak self] in
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this crashes if it's not on the main actor, right? Do we really need that?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a custom addition (maybe we should rename it to be more clear about it) which makes sure the closure runs on the main thread. Note that one can set any queue to be the one used for completion handers (default is main). This custom addition just checks if we are currently on the main, if yes, just call the closure, otherwise jump on the main thread (since UI updates and MainActor view models require main).

The difference between:
Task { @MainActor in and MainActor.ensureIsolated is that the latter is quicker.

DispatchQueue.global().async {
            print("Simulate API calls and DB updates")
            DispatchQueue.main.async {
                print(Thread.isMainThread)
                Task { @MainActor in
                    print("Task main actor")
                }
                MainActor.ensureIsolated {
                    print("Ensure isolated")
                }
            }
        }

If this snippet runs, ensure isolated is called first.

I did some micro benchmarking just for fun and the difference is insignificant: we are talking about 1-10 microseconds. Therefore, I guess it makes sense to simplify this and get rid of the custom ensureIsolated.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I renamed the custom addition to StreamConcurrency.onMain. I tried to get rid of it, but loads of unit-tests will break because using a Task introduces slight delay.

@@ -152,15 +152,16 @@ public struct MentionsCommandHandler: CommandHandler {

private func searchAllUsers(for typingMention: String) -> Future<SuggestionInfo, Error> {
Future { promise in
nonisolated(unsafe) let unsafePromise = promise
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should probably refactor this to use async

@@ -103,7 +103,12 @@ struct LazyGiphyView: View {
var body: some View {
LazyImage(imageURL: source) { state in
if let imageContainer = state.imageContainer {
NukeImage(imageContainer)
if imageContainer.type == .gif {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we change this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nuke dropped their image type and gif rendering support in Nuke 12.0 (migration guide). This is their recommendation how to handle gif rendering.

@@ -119,3 +124,18 @@ struct LazyGiphyView: View {
.aspectRatio(contentMode: .fit)
}
}

private struct AnimatedGifView: UIViewRepresentable {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did you take this from Nuke or is it a new implementation?

Copy link
Contributor Author

@laevandus laevandus May 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This I took from SwiftyGif's documentation as a recommended way for rendering gifs in SwiftUI. Not happy that I needed to touch gif rendering, but it is what it is.

Comment on lines 33 to 34
onDismiss: @escaping @MainActor() -> Void,
onError: @escaping @MainActor(Error) -> Void
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ouch, that's tricky, can we avoid it?

@laevandus laevandus marked this pull request as ready for review May 23, 2025 10:07
Copy link

Quality Gate Failed Quality Gate failed

Failed conditions
38.8% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
🪧 Demo App An Issue or PR related to the Demo App ⏫ Dependencies Update Pull requests that update a dependency file ✅ Feature An issue or PR related to a feature
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants